--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 1d52056a2deafcdadc301359261a773dbccf9f99
Parents : 5ca7308
Author : Sudo-Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-01-01T23:35:56-06:00
feat(profile): redesign ProfileIconPage with improved layout, color selection, and icon management features
Changes
1 files changed, 291 insertions(+), 111 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/profile/ProfileIconPage.vue b/meshchatx/src/frontend/components/profile/ProfileIconPage.vue
index 117bd95a..fa6edd2d 100644
--- a/meshchatx/src/frontend/components/profile/ProfileIconPage.vue
+++ b/meshchatx/src/frontend/components/profile/ProfileIconPage.vue
@@ -1,102 +1,201 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 dark:bg-zinc-950">
- <div class="overflow-y-auto space-y-2 p-2">
- <!-- info -->
- <div class="bg-white dark:bg-zinc-800 rounded shadow">
+ <div class="overflow-y-auto">
+ <div class="max-w-4xl mx-auto p-4 space-y-6">
+ <!-- Header with Preview -->
<div
- class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold"
+ class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
- Customise your Profile Icon
- </div>
- <div class="text-gray-900 dark:text-gray-100">
- <div class="text-sm p-2">
- <ul class="list-disc list-inside">
- <li>Personalise your profile with a custom coloured icon.</li>
- <li>This icon will be sent in all of your outgoing messages.</li>
- <li>When you send someone a message, they will see your new icon.</li>
- <li>
- You can
- <span class="cursor-pointer underline text-blue-500" @click="removeProfileIcon"
- >remove your icon</span
- >, however it will still show for anyone that already received it.
- </li>
- </ul>
+ <div class="p-6 border-b border-gray-200 dark:border-zinc-800">
+ <div class="flex items-center justify-between">
+ <div>
+ <h2 class="text-xl font-bold text-gray-900 dark:text-white">Profile Icon Customizer</h2>
+ <p class="text-sm text-gray-500 dark:text-zinc-400 mt-1">
+ Customize your profile icon that appears in all your messages
+ </p>
+ </div>
+ <div class="flex items-center gap-3">
+ <button
+ type="button"
+ :disabled="!hasChanges || isSaving"
+ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ :class="
+ hasChanges && !isSaving
+ ? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:border-blue-500 dark:hover:bg-blue-600'
+ : 'bg-gray-100 text-gray-700 border-gray-300 dark:bg-zinc-800 dark:text-zinc-300 dark:border-zinc-700'
+ "
+ @click="saveChanges"
+ >
+ <MaterialDesignIcon
+ v-if="isSaving"
+ icon-name="refresh"
+ class="size-4 animate-spin"
+ />
+ <MaterialDesignIcon v-else icon-name="content-save" class="size-4" />
+ {{ isSaving ? "Saving..." : "Save" }}
+ </button>
+ <button
+ type="button"
+ :disabled="!hasChanges || isSaving"
+ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 hover:bg-gray-50 dark:hover:bg-zinc-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ @click="resetChanges"
+ >
+ <MaterialDesignIcon icon-name="refresh" class="size-4" />
+ Reset
+ </button>
+ </div>
+ </div>
+ </div>
+ <div class="p-6">
+ <div class="flex flex-col items-center justify-center space-y-4">
+ <div class="text-sm font-medium text-gray-700 dark:text-zinc-300">Preview</div>
+ <div class="p-8 bg-gray-50 dark:bg-zinc-800 rounded-2xl">
+ <LxmfUserIcon
+ :icon-name="iconName"
+ :icon-foreground-colour="iconForegroundColour"
+ :icon-background-colour="iconBackgroundColour"
+ icon-class="size-16"
+ />
+ </div>
+ <div class="text-xs text-gray-500 dark:text-zinc-400 text-center max-w-md">
+ This is how your icon will appear to others when you send messages
+ </div>
+ </div>
</div>
</div>
- </div>
- <!-- colours -->
- <div class="bg-white dark:bg-zinc-800 rounded shadow">
+ <!-- Color Selection -->
<div
- class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold"
+ class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
- Select your Colours
- </div>
- <div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
- <!-- background colour -->
- <div class="p-2 flex space-x-2">
- <div class="flex my-auto">
- <ColourPickerDropdown v-model:colour="iconBackgroundColour" />
- </div>
- <div class="my-auto">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">Background Colour</div>
- <div class="text-sm text-gray-900 dark:text-gray-100">{{ iconBackgroundColour }}</div>
- </div>
+ <div class="p-4 border-b border-gray-200 dark:border-zinc-800">
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Colors</h3>
</div>
-
- <!-- icon colour -->
- <div class="p-2 flex space-x-2">
- <div class="flex my-auto">
- <ColourPickerDropdown v-model:colour="iconForegroundColour" />
+ <div class="p-4 space-y-4">
+ <div class="flex items-center justify-between gap-4">
+ <div class="flex-1">
+ <label class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
+ Background Color
+ </label>
+ <div class="flex items-center gap-3">
+ <ColourPickerDropdown v-model:colour="iconBackgroundColour" />
+ <div class="flex-1">
+ <input
+ v-model="iconBackgroundColour"
+ type="text"
+ class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+ placeholder="#e5e7eb"
+ />
+ </div>
+ </div>
+ </div>
</div>
- <div class="my-auto">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">Icon Colour</div>
- <div class="text-sm text-gray-900 dark:text-gray-100">{{ iconForegroundColour }}</div>
+ <div class="flex items-center justify-between gap-4">
+ <div class="flex-1">
+ <label class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
+ Icon Color
+ </label>
+ <div class="flex items-center gap-3">
+ <ColourPickerDropdown v-model:colour="iconForegroundColour" />
+ <div class="flex-1">
+ <input
+ v-model="iconForegroundColour"
+ type="text"
+ class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+ placeholder="#6b7280"
+ />
+ </div>
+ </div>
+ </div>
</div>
</div>
</div>
- </div>
- <!-- search icons -->
- <div class="bg-white dark:bg-zinc-800 rounded shadow">
+ <!-- Icon Selection -->
<div
- class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold"
+ class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
- Select your Icon
- </div>
- <div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
- <div class="flex p-1">
- <input
- v-model="search"
- type="text"
- :placeholder="`Search ${iconNames.length} icons...`"
- class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-600 dark:focus:border-blue-600 block w-full p-2.5"
- />
+ <div class="p-4 border-b border-gray-200 dark:border-zinc-800">
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Icon</h3>
</div>
- <div class="divide-y divide-gray-300 dark:divide-zinc-700">
+ <div class="p-4 space-y-4">
+ <div class="relative">
+ <input
+ v-model="search"
+ type="text"
+ :placeholder="`Search ${iconNames.length} icons...`"
+ class="w-full px-4 py-3 text-sm border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 placeholder-gray-400 dark:placeholder-zinc-500 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+ />
+ <MaterialDesignIcon
+ icon-name="magnify"
+ class="absolute right-3 top-1/2 -translate-y-1/2 size-5 text-gray-400 dark:text-zinc-500 pointer-events-none"
+ />
+ </div>
<div
- v-for="mdiIconName of searchedIconNames"
- :key="mdiIconName"
- class="flex space-x-2 p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-700"
- @click="onIconClick(mdiIconName)"
+ class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 max-h-[500px] overflow-y-auto p-1"
>
- <div class="my-auto">
+ <div
+ v-for="mdiIconName of searchedIconNames"
+ :key="mdiIconName"
+ class="flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all hover:bg-gray-50 dark:hover:bg-zinc-800 hover:border-blue-500 dark:hover:border-blue-500"
+ :class="
+ iconName === mdiIconName
+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
+ : 'border-gray-200 dark:border-zinc-700'
+ "
+ @click="onIconClick(mdiIconName)"
+ >
<LxmfUserIcon
:icon-name="mdiIconName"
:icon-foreground-colour="iconForegroundColour"
:icon-background-colour="iconBackgroundColour"
+ icon-class="size-8"
/>
+ <div
+ class="mt-2 text-xs text-center text-gray-600 dark:text-zinc-400 truncate w-full"
+ :title="mdiIconName"
+ >
+ {{ mdiIconName }}
+ </div>
</div>
- <div class="my-auto">{{ mdiIconName }}</div>
</div>
- <div v-if="searchedIconNames.length === 0" class="p-1 text-sm text-gray-500">
+ <div
+ v-if="searchedIconNames.length === 0"
+ class="text-center py-8 text-sm text-gray-500 dark:text-zinc-400"
+ >
No icons match your search.
</div>
- <div v-if="searchedIconNames.length === maxSearchResults" class="p-1 text-sm text-gray-500">
- A maximum of {{ maxSearchResults }} icons are shown.
+ <div
+ v-if="searchedIconNames.length === maxSearchResults"
+ class="text-center py-2 text-xs text-gray-500 dark:text-zinc-400"
+ >
+ Showing first {{ maxSearchResults }} results. Refine your search to see more.
</div>
</div>
</div>
+
+ <!-- Remove Icon Section -->
+ <div
+ class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
+ >
+ <div class="p-4 border-b border-gray-200 dark:border-zinc-800">
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Remove Icon</h3>
+ </div>
+ <div class="p-4">
+ <p class="text-sm text-gray-600 dark:text-zinc-400 mb-4">
+ Remove your profile icon. Anyone who has already received it will continue to see it until
+ you send them a new icon.
+ </p>
+ <button
+ type="button"
+ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-red-300 dark:border-red-800 bg-white dark:bg-zinc-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
+ @click="removeProfileIcon"
+ >
+ <MaterialDesignIcon icon-name="delete-outline" class="size-4" />
+ Remove Icon
+ </button>
+ </div>
+ </div>
</div>
</div>
</div>
@@ -105,115 +204,196 @@
<script>
import * as mdi from "@mdi/js";
import LxmfUserIcon from "../LxmfUserIcon.vue";
-import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
import ColourPickerDropdown from "../ColourPickerDropdown.vue";
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "ProfileIconPage",
components: {
ColourPickerDropdown,
LxmfUserIcon,
+ MaterialDesignIcon,
},
data() {
return {
config: null,
+ iconName: null,
iconForegroundColour: null,
iconBackgroundColour: null,
+ originalIconName: null,
+ originalIconForegroundColour: null,
+ originalIconBackgroundColour: null,
+
search: "",
- maxSearchResults: 100,
+ maxSearchResults: 200,
iconNames: [],
+
+ isSaving: false,
+ autoSaveTimeout: null,
};
},
computed: {
searchedIconNames() {
+ const searchLower = this.search.toLowerCase();
return this.iconNames
.filter((iconName) => {
- return iconName.includes(this.search);
+ return iconName.toLowerCase().includes(searchLower);
})
.slice(0, this.maxSearchResults);
},
+ hasChanges() {
+ return (
+ this.iconName !== this.originalIconName ||
+ this.iconForegroundColour !== this.originalIconForegroundColour ||
+ this.iconBackgroundColour !== this.originalIconBackgroundColour
+ );
+ },
},
watch: {
config() {
- // update ui when config is updated
- this.iconName = this.config.lxmf_user_icon_name;
- this.iconForegroundColour = this.config.lxmf_user_icon_foreground_colour || "#6b7280";
- this.iconBackgroundColour = this.config.lxmf_user_icon_background_colour || "#e5e7eb";
+ if (this.config) {
+ this.iconName = this.config.lxmf_user_icon_name || null;
+ this.iconForegroundColour = this.config.lxmf_user_icon_foreground_colour || "#6b7280";
+ this.iconBackgroundColour = this.config.lxmf_user_icon_background_colour || "#e5e7eb";
+
+ this.saveOriginalValues();
+ }
+ },
+ iconForegroundColour() {
+ this.debouncedAutoSave();
+ },
+ iconBackgroundColour() {
+ this.debouncedAutoSave();
+ },
+ iconName() {
+ this.debouncedAutoSave();
},
},
mounted() {
this.getConfig();
- // load icon names
this.iconNames = Object.keys(mdi).map((mdiIcon) => {
return mdiIcon
- .replace(/^mdi/, "") // Remove the "mdi" prefix
- .replace(/([a-z])([A-Z])/g, "$1-$2") // Add a hyphen between lowercase and uppercase letters
- .toLowerCase(); // Convert the entire string to lowercase
+ .replace(/^mdi/, "")
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
+ .toLowerCase();
});
},
+ beforeUnmount() {
+ if (this.autoSaveTimeout) {
+ clearTimeout(this.autoSaveTimeout);
+ }
+ },
methods: {
+ saveOriginalValues() {
+ this.originalIconName = this.iconName;
+ this.originalIconForegroundColour = this.iconForegroundColour;
+ this.originalIconBackgroundColour = this.iconBackgroundColour;
+ },
+ debouncedAutoSave() {
+ if (this.autoSaveTimeout) {
+ clearTimeout(this.autoSaveTimeout);
+ }
+
+ this.autoSaveTimeout = setTimeout(() => {
+ if (this.hasChanges && this.iconName && this.iconForegroundColour && this.iconBackgroundColour) {
+ this.saveChanges(true);
+ }
+ }, 1000);
+ },
async getConfig() {
try {
const response = await window.axios.get("/api/v1/config");
this.config = response.data.config;
} catch (e) {
- // do nothing if failed to load config
- console.log(e);
+ ToastUtils.error("Failed to load configuration");
+ console.error(e);
}
},
- async updateConfig(config) {
+ async updateConfig(config, silent = false) {
try {
const response = await window.axios.patch("/api/v1/config", config);
this.config = response.data.config;
+ this.saveOriginalValues();
+
+ if (!silent) {
+ ToastUtils.success("Profile icon saved successfully");
+ }
+ return true;
} catch (e) {
- ToastUtils.error("Failed to save config!");
- console.log(e);
+ if (!silent) {
+ ToastUtils.error("Failed to save profile icon");
+ }
+ console.error(e);
+ return false;
}
},
- async onIconClick(iconName) {
- // ensure foreground colour set
- if (this.iconForegroundColour == null) {
- DialogUtils.alert("Please select an icon colour first");
+ async saveChanges(silent = false) {
+ if (!this.hasChanges) {
return;
}
- // ensure background colour set
- if (this.iconBackgroundColour == null) {
- DialogUtils.alert("Please select a background colour first");
+ if (!this.iconForegroundColour || !this.iconBackgroundColour) {
+ ToastUtils.warning("Please select both background and icon colors");
return;
}
- // confirm user wants to update their icon
- if (!(await DialogUtils.confirm("Are you sure you want to set this as your profile icon?"))) {
+ if (!this.iconName) {
+ ToastUtils.warning("Please select an icon");
return;
}
- // save icon appearance
- await this.updateConfig({
- lxmf_user_icon_name: iconName,
- lxmf_user_icon_foreground_colour: this.iconForegroundColour,
- lxmf_user_icon_background_colour: this.iconBackgroundColour,
- });
+ this.isSaving = true;
+
+ try {
+ const success = await this.updateConfig(
+ {
+ lxmf_user_icon_name: this.iconName,
+ lxmf_user_icon_foreground_colour: this.iconForegroundColour,
+ lxmf_user_icon_background_colour: this.iconBackgroundColour,
+ },
+ silent
+ );
+
+ if (success && !silent) {
+ ToastUtils.success("Profile icon saved successfully");
+ }
+ } finally {
+ this.isSaving = false;
+ }
},
- async removeProfileIcon() {
- // confirm user wants to remove their icon
- if (
- !(await DialogUtils.confirm(
- "Are you sure you want to remove your profile icon? Anyone that has already received it will continue to see it until you send them a new icon."
- ))
- ) {
+ resetChanges() {
+ if (!this.hasChanges) {
return;
}
- // remove profile icon
- await this.updateConfig({
- lxmf_user_icon_name: null,
- lxmf_user_icon_foreground_colour: null,
- lxmf_user_icon_background_colour: null,
- });
+ this.iconName = this.originalIconName;
+ this.iconForegroundColour = this.originalIconForegroundColour;
+ this.iconBackgroundColour = this.originalIconBackgroundColour;
+
+ ToastUtils.info("Changes reset to saved values");
+ },
+ onIconClick(iconName) {
+ this.iconName = iconName;
+ },
+ async removeProfileIcon() {
+ this.isSaving = true;
+
+ try {
+ const success = await this.updateConfig({
+ lxmf_user_icon_name: null,
+ lxmf_user_icon_foreground_colour: null,
+ lxmf_user_icon_background_colour: null,
+ });
+
+ if (success) {
+ ToastUtils.success("Profile icon removed successfully");
+ }
+ } finally {
+ this.isSaving = false;
+ }
},
},
};
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────